Skip to content

New mode of head-polling using client-side state management only - #225

Open
peterbroadhurst wants to merge 11 commits into
mainfrom
client-side-filtering
Open

New mode of head-polling using client-side state management only#225
peterbroadhurst wants to merge 11 commits into
mainfrom
client-side-filtering

Conversation

@peterbroadhurst

@peterbroadhurst peterbroadhurst commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

There are cases where a stable WebSocket or sticky-session enabled HTTP load balancer is not available to connect to a bank of RPC nodes for an EVM blockchain.

In these cases relying on the server-side filter management provided by execution clients like Besu/Erigon/Reth/Nethermind etc. via eth_newFilter / eth_getFilterChanges is impossible, as every HTTP request gets routed to a new node and the filter is constantly lost and recreated.

This PR proposes a new mode of operation where no server-side state is required during either catchup or steady-state operation of the block listener. This is supported in both light and full block tracking modes.

The architecture of the new mode of operation is described in this comment, and I've called out line-level comments on other areas where I've made changes:

// leadGroupSteadyStateGetLogs is the alternative steady state to leadGroupSteadyState, selected with
// events.filterPollingMode: client.
//
// Instead of establishing a node-side filter, we track our own in-memory poll position and page
// forwards with stateless eth_getLogs range queries.
//
// Detection in this function is decoupled from confirmation (in FFTM).
// The confirmation manager waits for its own configured number of confirmations after each
// event's block (immediate for an event that arrives already deep enough),
// delivers to the application, and waits for the ack.
//
// For light mode that is based just on a comparison of event vs. head block numbers.
// For full mode there is a client-side tracking of the full unstable head and the confirmation
// list is re-calculated client-side.
//
// Details on polling approach:
//
// - FULL: We take a snapshot of the block listener's current view of the unstable head
// of the chain before we poll for events. Each poll we check if a new fork is apparent
// and re-poll from the position of the new fork in the chain.
// While we cannot be certain the chain we poll is the same we had a client-side view of
// before the poll, we know we will detect further divergence on subsequent poll cycles
// (as long as it occurs within the checkpointBlockGap).
//
// - LIGHT: Full blocks are not available to compare, so instead we re-scan the whole unstable
// window (the checkpointBlockGap blocks below the head) on every sweep, de-duplicating what
// we already delivered by block hash.
//
// Checkpoints come from two places:
//
// - Ack-based: each delivered event carries its own checkpoint, persisted by FFTM as batches
// are acknowledged. When events are flowing, the checkpoint moves forwards with delivery.
//
// - Inactivity: when no events are flowing, FFTM periodically polls our high water mark (see
// getHWM) recording how far we have scanned, held back checkpointBlockGap from the head.
// The LastDetected floor in that response stops an inactivity checkpoint overtaking a
// detected-but-unacknowledged event, so anything FFTM had not finished delivering is
// re-detected after a crash, at any confirmation count.
//
// This means on restart, processing will either:
//
// - Continue from the most recent confirmed acknowledged event, which could be the
// confirmation count back from the head (configured to 6 or 10 etc.).
// - Continue at least checkpointBlockGap behind wherever the head was before the restart,
// possibly significantly longer based on when the inactivity checkpoint was captured.
func (es *eventStream) leadGroupSteadyStateGetLogs() bool {

Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
}
// HWM is the configured fromBlock
l.hwmBlock = int64(firstBlock) //nolint:gosec // convert to int64 to match the type of hwmBlock, we should change the type of hwmBlock to uint64
l.hwmBlock = blockNumberToInt64(firstBlock)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this was getting proliferated, I've condensed to a single place and a single behavior if we ever ended up with the (invalid) case of a block in the >maxint64 range.

Comment on lines +504 to +509
var exiting bool
if es.c.eventFilterPollingMode == FilterPollingModeClient {
exiting = es.leadGroupSteadyStateGetLogs()
} else {
exiting = es.leadGroupSteadyState()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberately a hard split here, to protect the existing code path from churn.

Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Comment on lines +276 to +285
// Catchup only polls blocks that are outside the re-org unstable window at the head of
// the chain (checkpointBlockGap behind the head).
// The steady-state loops own delivery of the unstable window.
// We stop on the first page where the end lands between catchupThreshold+checkpointBlockGap
// (say 550) and the checkpointBlockGap (say 50) before the head to do the switch.
pollableHead := blockNumberToInt64(chainHeadBlock) - es.c.checkpointBlockGap
if pollableHead < 0 {
pollableHead = 0
}
headGap := pollableHead - fromBlock

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might seem decoupled from the primary feature, but this precision on the right change-over point from catchup to steady-state is more important for client-side filtering (particularly in light mode).

In client-side filtering we just maintain a block number, and page forwards from there. Things behind the earliest block we consume from are completely ignored. So the steady state looks a lot like the catchup mode, but we are extra vigilant to re-detect things in the in the checkpointBlockGap (the unstable part).

So it's really important there's no case where this catchup never goes beyond that checkpointBlockGap, and before there was an edge case where the last page could land in that unstable window.

Note the window only existed in the leadGroupCatchup path.

The listenerCatchupLoop that is used when a new listener is added that's behind the lead group already had the strong protection against joining the lead group in the checkpointBlockGap.

Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
@peterbroadhurst
peterbroadhurst marked this pull request as ready for review September 3, 2026 12:56
@peterbroadhurst
peterbroadhurst requested a review from a team as a code owner September 3, 2026 12:56
Base automatically changed from ws-only-listener to main September 4, 2026 06:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant